Skip to main content

Function decoration for backoff and retry

Project description

backon

Function decoration for backoff and retry — modern, fast, zero dependencies.

backon is a modern evolution of backoff — a zero-dependency Python library for retry with exponential backoff. It provides decorator, functional, and context manager APIs for both sync and async code.

demo


Table of Contents


Features

  • Zero dependencies — pure Python, stdlib only
  • Four APIs — decorator (@on_exception, @on_predicate), functional (retry()), context manager (Retrying), callable (RetryingCaller / AsyncRetryingCaller)
  • Async native — same API works for async def functions
  • Generator native — sync and async generators are retried transparently
  • Full type hints — validated with mypy, py.typed included
  • Global togglebackon.disable() / backon.enable() for testing
  • Custom sleep — inject your own sleep function (useful for testing with asyncio.Event)
  • Multiple wait strategies — exponential, constant, Fibonacci, decay, runtime, randomized, incremental, and composable chains
  • wait_combine() — sum multiple wait strategies per retry step
  • retry_with() — override retry parameters per-call on decorated functions
  • Jitter — full jitter, random jitter, or none
  • Rich callbackson_attempt, on_backoff, on_success, on_giveup, before_sleep, before, after
  • Circuit breaker — CLOSED/OPEN/HALF_OPEN states with automatic recovery
  • Hedging — concurrent retry requests, first-success-wins
  • Prometheus / OpenTelemetry / structlog metrics — optional, zero hard dependencies
  • Testing moduledisable_retries(), limit_retries(), remove_backoff(), assert_retried()
  • Trio support — retry with the trio async framework
  • Operator overloading — compose stops with | / &, wait generators with +
  • Iterator APIfor attempt in Retrying(...):
  • Modern packaging — PEP 621, PDM, py.typed

Installation

pip install backon

Requires Python 3.10+.


Quick Start

Retry on exception

import backon

@backon.on_exception(backon.expo, ValueError, max_tries=3)
def fetch_data():
    return api.call()

Retry on predicate

@backon.on_predicate(backon.constant, max_tries=5, interval=0.5)
def poll_status():
    return check_ready()

Functional API

result = backon.retry(
    fetch_data,
    backon.expo,
    exception=ValueError,
    max_tries=3,
)

Context manager

with backon.Retrying(backon.expo, exception=ValueError, max_tries=3) as r:
    result = r.call(fetch_data)

Async variant:

async with backon.Retrying(backon.constant, exception=ValueError, max_tries=3, interval=0.5) as r:
    result = await r.async_call(fetch_data)

API Reference

Decorators

@backon.on_exception(wait_gen, exception, ...)

Retry when the decorated function raises one of the specified exceptions.

@backon.on_exception(backon.expo, (ValueError, TimeoutError), max_tries=5)
def fetch():
    ...
Argument Type Default Description
wait_gen WaitGenerator Wait strategy (expo, constant, fibo, etc.)
exception type or tuple[type] Exception class(es) to retry on
max_tries int or Callable[[], int] None Maximum number of attempts
max_time float, timedelta, or Callable None Maximum total elapsed time
jitter Jitterer or None full_jitter Jitter function
giveup Callable[[Exception], bool or float] lambda e: False Stop retrying for matching exceptions; return float to override wait
on_success Handler or list None Called after successful attempt
on_backoff Handler or list None Called before each retry
on_giveup Handler or list None Called when retries exhausted
on_attempt Handler or list None Called before each attempt
before_sleep Handler or list None Called before sleeping
before Handler or list None Called before each attempt (lower-level than on_attempt)
after Handler or list None Called after each attempt (lower-level than on_success/on_giveup)
retry_error_callback Callable[[dict], Any] None Called when retry gives up instead of raising
raise_on_giveup bool True Raise final exception when giving up
logger str or Logger "backon" Logger name or instance
backoff_log_level int logging.INFO Log level for backoff messages
giveup_log_level int logging.ERROR Log level for giveup messages
sleep Callable[[float], Any] None Custom sleep function
rate_limit RateLimiter or None None Rate limiter to throttle retry calls
attempt_timeout float or None None Maximum time in seconds for a single attempt
**wait_gen_kwargs varies Extra kwargs passed to the wait generator (e.g. base=3, interval=0.5)

@backon.on_predicate(wait_gen, predicate, ...)

Retry while the predicate matches the return value.

@backon.on_predicate(backon.constant, predicate=lambda x: x is None, max_tries=5)
def poll():
    ...

Accepts all parameters from on_exception except exception and giveup. Adds:

Argument Type Default Description
predicate Callable[[Any], bool] operator.not_ Retry when this returns True for the return value

decorator.retry_with(**overrides)

Every decorated function (sync, async, generator) exposes a .retry_with() method that returns a new decorated function with overridden parameters:

@backon.on_exception(backon.expo, ValueError, max_tries=5)
def fetch():
    ...

# Override max_tries
wrapped = fetch.retry_with(max_tries=3)

# Override jitter
wrapped = fetch.retry_with(jitter=None)

# Override wait generator and sleep
wrapped = fetch.retry_with(wait_gen=backon.constant, interval=0.1, sleep=lambda s: None)

The original decorated function is unaffected.

Functional API

backon.retry(target, wait_gen, ...)

result = backon.retry(
    target=my_function,
    wait_gen=backon.expo,
    exception=ValueError,
    max_tries=3,
)

Accepts all parameters from on_exception plus on_predicate extras, plus:

Argument Type Default Description
condition RetryCondition None Advanced retry condition object
stop Stop None Advanced stop condition object
name str "" Identifier for the retry call
**wait_gen_kwargs varies Extra kwargs passed to the wait generator

If target is a coroutine function, retry() returns a coroutine. Otherwise it returns the result synchronously.

Context Manager

backon.Retrying(wait_gen, ...)

with backon.Retrying(backon.expo, exception=ValueError, max_tries=3) as r:
    r.call(my_function)

async with backon.Retrying(backon.constant, exception=ValueError, max_tries=3, interval=0.5) as r:
    await r.async_call(my_async_function)
Method Description
call(target, *args, **kwargs) Execute synchronously
async_call(target, *args, **kwargs) Execute asynchronously
copy() Return a modified copy of the Retrying instance
statistics Property returning dict with attempt_number, elapsed, idle_for, start_time
call_state Property returning the current RetryCallState
enabled Property to enable/disable retry per-instance

Arguments: Same as retry(), plus enabled (default True).

Callers

backon.RetryingCaller(wait_gen, ...)

A callable object with pre-bound exception type via .on().

caller = backon.RetryingCaller(backon.expo, max_tries=3)
caller = caller.on(ValueError)

result = caller(my_function, arg1, arg2)

backon.AsyncRetryingCaller(wait_gen, ...)

Async variant of RetryingCaller.

caller = backon.AsyncRetryingCaller(backon.expo, max_tries=3).on(ValueError)
result = await caller(my_async_function, arg1, arg2)
Method Description
.on(exception) Return a copy bound to the given exception type
.copy() Return a modified copy
.__call__(target, *args, **kwargs) Execute with retry

Wait Generators

All wait generators are callables that produce a sequence of wait times. Pass extra kwargs (e.g. interval=0.5, base=3) as **wait_gen_kwargs to decorators and functions.

Generator Signature Description
expo (base=2, factor=1, max_value=None) Exponential backoff: factor * base^n
constant (interval=1) Fixed interval; accepts float or Sequence[float] for varied intervals
fibo (max_value=None) Fibonacci sequence: 1, 1, 2, 3, 5, 8, ...
runtime (value=Callable) Dynamic wait from return value or exception — useful for Retry-After headers
decay (initial_value=1, decay_factor=1, min_value=None) Exponential decay: initial * e^(-t * decay_factor)
wait_random_exponential (multiplier=1, max_value=None, exp_base=2, min_value=0) Randomized exponential (uniform random between 0 and the exponential value)
wait_incrementing (start=1, increment=1, max_value=None) Linear increment: start + n * increment
wait_chain (*generators) Sequentially play through multiple generators
wait_combine (*generators) Sum all generator wait values per step (unlike + which chains sequentially)
wait_exception (value=Callable) Dynamic wait based on the caught exception
wait_random (min=0, max=1) Uniform random wait between min and max
wait_exponential_jitter (initial=1, max=60, exp_base=2, jitter=1) Exponential backoff with added random jitter
wait_none () Always returns 0 (no wait)

Composition: Combine wait generators with + (sequential chain) or wait_combine (sum per step):

# Sequential: wait_chain(expo, constant)
wait_strategy = backon.expo(base=3) + backon.constant(interval=0.5)

# Sum per step: wait_combine(expo, constant) — same kwargs to all sub-generators
from backon import wait_combine

@backon.on_exception(wait_combine(backon.expo, backon.constant), ValueError, max_tries=3)
def fetch():
    ...

wait_combine calls all sub-generators with the same kwargs on each step and returns the sum — useful when you want combined behaviors on every retry rather than a sequence.


Stop Conditions

Stop conditions determine when retry should cease. They can be composed with | (any) and & (all).

Condition Description
stop_after_attempt(max_attempts) Stop after N attempts
stop_after_delay(max_delay) Stop after total elapsed time exceeds max_delay seconds
stop_before_delay(max_delay) Stop if the next wait would exceed max_delay
stop_all(*stops) Stop when all sub-conditions are met
stop_any(*stops) Stop when any sub-condition is met
stop_never() Never stop (retry indefinitely)
stop_when_event_set(event) Stop when a threading.Event is set
from backon import stop_after_attempt, stop_after_delay, stop_any

stop = stop_after_attempt(5) | stop_after_delay(30.0)

Retry Conditions

Retry conditions determine whether a retry should happen. They can be composed with | and &.

Condition Description
retry_if_exception_type(exc_types) Retry if exception is an instance of given type(s) — accepts a single type or a tuple of types
retry_if_exception(predicate) Retry if the exception matches a custom predicate
retry_if_exception_message(message, match=None) Retry if exception message contains a string (or matches regex with match="re")
retry_if_result(predicate) Retry if the return value matches a predicate
retry_if_not_result(predicate) Retry if the return value does NOT match a predicate
retry_all(*conditions) Retry only when all conditions pass
retry_any(*conditions) Retry when any condition passes
retry_always() Always retry
retry_never() Never retry
retry_if_exception_cause_type(exc_types) Retry if the exception's cause chain matches the given type(s)
retry_if_not_exception_type(exc_types) Retry if exception is NOT an instance of the given type(s)
retry_if_not_exception_message(match, regex=False) Retry if exception message does NOT contain the given string
retry_unless_exception_type(exc_types) Alias for retry_if_not_exception_type
from backon import retry_if_exception_type, retry_if_exception_message, retry_all

condition = retry_all(
    retry_if_exception_type(HTTPError),
    retry_if_exception_message("429"),
)

Jitter

@backon.on_exception(backon.expo, ValueError, jitter=backon.full_jitter)
def f():
    ...
Jitter Effect
backon.full_jitter Random value between 0 and the calculated wait time
backon.random_jitter Adds random() to the calculated wait time (~+0.5s on average)
None No jitter (deterministic waits)

Handlers

Handlers receive a details dict with contextual information:

def handler(details):
    print(f"Attempt {details['tries']}, elapsed {details['elapsed']:.2f}s")

@backon.on_exception(
    backon.expo, ValueError, max_tries=3,
    on_attempt=handler,
    on_backoff=handler,
    on_success=handler,
    on_giveup=handler,
)
def f():
    ...

Available keys in details:

Key Available in
target All
args, kwargs All
tries All
elapsed All
value on_success, on_backoff, on_giveup
exception on_backoff, on_giveup
wait on_backoff, before_sleep

Global Toggle

Useful in tests to disable retry logic globally:

backon.disable()   # skip retry, call function directly
backon.enable()    # re-enable retry

Per-instance toggle via Retrying.enabled:

r = backon.Retrying(backon.expo, exception=ValueError, max_tries=3)
r.enabled = False
result = r.call(fn)  # no retry

Generator Support

Sync and async generator functions are retried transparently. On each retry, the generator is restarted from scratch.

@backon.on_exception(backon.expo, ValueError, max_tries=3)
def gen():
    yield 1
    yield 2
    raise ValueError("fail")
    yield 3  # reached on retry

result = list(gen())  # [1, 2, 3]

@backon.on_exception(backon.expo, ValueError, max_tries=3)
async def agen():
    yield 1
    raise ValueError("fail")
    yield 2

result = [item async for item in agen()]  # [1, 2]

retry_with() works on generators too:

wrapped = gen.retry_with(max_tries=5)

Async Support

All three APIs work with async functions transparently:

@backon.on_exception(backon.expo, ValueError, max_tries=3)
async def fetch():
    return await api.call()

result = await backon.retry(fetch, backon.expo, exception=ValueError, max_tries=3)

async with backon.Retrying(backon.expo, exception=ValueError, max_tries=3) as r:
    result = await r.async_call(fetch)

Custom Sleep

Replace the default sleep for testing or special environments:

@backon.on_exception(
    backon.expo, ValueError, max_tries=3,
    sleep=lambda s: print(f"waiting {s}s"),
)
def f():
    ...

# With asyncio.Event for testing
import asyncio

event = asyncio.Event()
@backon.on_exception(
    backon.expo, ValueError, max_tries=3,
    sleep=backon.sleep_using_event(event),
)
async def f():
    ...

Advanced Features

Rate Limiter

Throttle retry calls to avoid overwhelming a backend.

from backon import RateLimiter

limiter = RateLimiter(max_calls=10, period=1.0)  # max 10 calls per second

@backon.on_exception(backon.expo, ValueError, max_tries=5, rate_limit=limiter)
def fetch():
    ...

RateLimitError is raised when the rate limit is exceeded outside of retry context.

TryAgain

Raise TryAgain inside a retried function to force an immediate retry, bypassing any condition or stop logic:

import backon
from backon import TryAgain

@backon.on_exception(backon.expo, ValueError, max_tries=3)
def fetch():
    try:
        return api.call()
    except TemporaryIssue:
        raise TryAgain()

Circuit Breaker

Circuit breaker with three states: CLOSED (normal), OPEN (failing), HALF_OPEN (testing recovery).

from backon._circuit_breaker import CircuitBreaker, BreakerRetrying, CircuitOpenError

breaker = BreakerRetrying(
    backon.expo, max_tries=3,
    breaker=CircuitBreaker(
        failure_threshold=5,
        recovery_timeout=60.0,
        half_open_max_calls=1,
    ),
)

try:
    result = breaker.call(fetch)
except CircuitOpenError:
    print("Circuit is open, skipping request")
CircuitBreaker parameter Default Description
failure_threshold 5 Consecutive failures before opening the circuit
recovery_timeout 60.0 Seconds before transitioning from OPEN to HALF_OPEN
half_open_max_calls 1 Allowed calls in HALF_OPEN state before fully closing
name "" Identifier for the breaker

Hedging

Run multiple retry attempts concurrently and return the first success.

from backon._hedging import hedge, HedgingRetrying

# Functional
result = hedge(fetch, backon.expo, max_hedge=3)

# Decorator
@backon.on_hedge(backon.expo, max_hedge=3)
def fetch():
    ...

# Context manager
with HedgingRetrying(backon.expo, max_hedge=3) as h:
    result = h.call(fetch)
Parameter Default Description
max_hedge 3 Number of concurrent hedged requests
timeout None Maximum time to wait for any hedge
on_hedge None Callback when a hedge request is sent

Metrics

Optional Prometheus, OpenTelemetry, and structlog metrics. Each requires its corresponding package to be installed.

from backon._instrumentation import PrometheusMetrics, OTelMetrics, StructlogMetrics, set_metrics_collector

# Prometheus
set_metrics_collector(PrometheusMetrics())

# OpenTelemetry
set_metrics_collector(OTelMetrics(meter_name="myapp.backon"))

# Structlog (structured logging)
set_metrics_collector(StructlogMetrics())

Auto-detection (first available: prometheus_client > structlog > no-op):

from backon._instrumentation import _auto_detect_collector
set_metrics_collector(_auto_detect_collector())

Metrics collected:

  • backon_retry_attempts_total (attempts, labeled by target and exception type)
  • backon_retry_success_total (successes)
  • backon_retry_failure_total (failures)
  • backon_circuit_breaker_open_total / backon_circuit_breaker_close_total
  • backon_hedge_requests_total
  • backon.retry.attempt_duration (histogram, OTel only)

Testing Utilities

from backon._testing import (
    disable_retries, enable_retries,
    test_config, limit_retries, remove_backoff,
    assert_retried, assert_not_retried,
)

# Context manager that skips retry for a block
with disable_retries():
    result = fetch()

# Limit max retries in tests
with limit_retries(2):
    fetch()

# Remove backoff delay entirely
with remove_backoff():
    fetch()

# Assert the function was retried N times
assert_retried(fetch, expected_tries=3)

Trio Support

Retry with the trio async framework:

from backon._trio import retry_exception, retry_predicate

@retry_exception(backon.expo, ValueError, max_tries=3)
async def fetch():
    ...

Requires trio to be installed.

Retry Context Inspection

Check if code is running inside a retry and get the current attempt number anywhere in the call stack:

from backon import is_retrying, get_attempt_number

def log_attempt():
    if is_retrying():
        print(f"This is attempt #{get_attempt_number()}")

@backon.on_exception(backon.expo, ValueError, max_tries=3)
def fetch():
    log_attempt()
    return api.call()

Uses contextvars — thread-safe and async-safe.

Dynamic Backoff

Override the wait time per attempt by returning a float from the giveup callback. Useful for respecting Retry-After headers.

def respect_retry_after(exc: HTTPError) -> float:
    return exc.response.headers.get("Retry-After", 1.0)

@backon.on_exception(backon.expo, HTTPError, giveup=respect_retry_after)
def fetch():
    ...

Hot Loop Detection

When 5 or more retries occur with less than 100ms between them, backon logs a warning. This helps detect misconfigured retry policies before they cause issues.

Retry Statistics

r = backon.Retrying(backon.expo, exception=ValueError, max_tries=3)
result = r.call(fetch)

print(r.statistics)
# {'start_time': ..., 'attempt_number': 2, 'idle_for': 1.5, 'elapsed': 2.3}

print(r.call_state)
# RetryCallState(fn=..., attempt_number=2, ...)

Operator Composition

Compose stops, conditions, and wait generators using Python operators:

# Stop when either condition is met
stop = stop_after_attempt(5) | stop_after_delay(30.0)

# Retry when both conditions pass
cond = retry_if_exception_type(TimeoutError) & retry_if_result(lambda x: x is None)

# Wait with combined strategy
wait = backon.expo(base=3) + backon.constant(interval=0.5)

Iterator API

for attempt in backon.Retrying(backon.expo, exception=ValueError, max_tries=3):
    with attempt:
        result = fetch()
    if not attempt.failed:
        break

Migrating from backoff

backon is a near-drop-in replacement. Change your imports:

- import backoff
+ import backon

- @backoff.on_exception(backoff.expo, ValueError, max_tries=3)
+ @backon.on_exception(backon.expo, ValueError, max_tries=3)

Key differences:

Area backoff backon
Python support 3.7+ 3.10+
Type hints Partial Full
on_attempt callback Not supported Supported
Context manager Not supported Retrying class
Functional API Not supported retry() function, RetryingCaller
Global toggle Not supported disable() / enable()
Custom sleep Not supported sleep= parameter
Circuit breaker Not supported CircuitBreaker + BreakerRetrying
Hedging Not supported hedge() / on_hedge()
Metrics Not supported Prometheus / OTel
Wait generator composition Not supported + operator
Stop / RetryCondition composition Not supported | / & operators
Trio Not supported import from backon._trio
Iterator API Not supported for attempt in Retrying():
Build system Poetry PDM (PEP 621)

Contributing

git clone https://github.com/Llucs/backon.git
cd backon
pip install pdm
pdm install
pdm run ruff check backon/ tests/
pdm run mypy backon/
pdm run pytest tests/ -q

License

MIT

Made by Llucs with ❤️

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

backon-3.8.0.tar.gz (57.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

backon-3.8.0-py3-none-any.whl (40.1 kB view details)

Uploaded Python 3

File details

Details for the file backon-3.8.0.tar.gz.

File metadata

  • Download URL: backon-3.8.0.tar.gz
  • Upload date:
  • Size: 57.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for backon-3.8.0.tar.gz
Algorithm Hash digest
SHA256 8e3eae8a932713b463436906fe6aac5f63342048cb392fd8cecb1055c5e47c33
MD5 7f8663587e897f1e56fb8902da3b0301
BLAKE2b-256 96e6a13288eb17bcd504589e0a489a3f3c01c292dbec917ebb30278f93f70832

See more details on using hashes here.

Provenance

The following attestation bundles were made for backon-3.8.0.tar.gz:

Publisher: release.yml on Llucs/backon

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file backon-3.8.0-py3-none-any.whl.

File metadata

  • Download URL: backon-3.8.0-py3-none-any.whl
  • Upload date:
  • Size: 40.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for backon-3.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cabcfe3b6e6e9c6798eed559ea91fa22f3cfac2846eeed61f0b47bb8abef74d9
MD5 0af4cf6aeeae74cd9e18c053aeb1d7d4
BLAKE2b-256 064c6011d33bbacbb38ac7cd88b5a318786330c60a4c676191365bd8ec7629d5

See more details on using hashes here.

Provenance

The following attestation bundles were made for backon-3.8.0-py3-none-any.whl:

Publisher: release.yml on Llucs/backon

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page